// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

/* eslint-disable guard-for-in */
/* eslint-disable @typescript-eslint/no-use-before-define */

import { spawnSync } from 'child_process';
import * as fs from 'fs-extra';
import { default as minimatch } from 'minimatch';
import npmlog from 'npmlog';
import * as os from 'os';
import * as path from 'path';
import { inspect } from 'util';

import { onnx } from '../lib/onnxjs/ort-schema/protobuf/onnx';
import { bufferToBase64 } from '../test/test-shared';
import { Test } from '../test/test-types';

import { parseTestRunnerCliArgs, TestRunnerCliArgs } from './test-runner-cli-args';

async function main() {
  // use dynamic import so that we can use ESM only libraries in commonJS.
  const { globbySync } = await import('globby');
  const stripJsonComments = (await import('strip-json-comments')).default;

  npmlog.info('TestRunnerCli', 'Initializing...');

  const args = parseTestRunnerCliArgs(process.argv.slice(2));

  npmlog.verbose('TestRunnerCli.Init.Config', inspect(args));

  const DIST_ROOT = path.join(__dirname, '..', 'dist');
  const TEST_ROOT = path.join(__dirname, '..', 'test');
  const TEST_DATA_MODEL_NODE_ROOT = path.join(TEST_ROOT, 'data', 'node');
  const TEST_DATA_OP_ROOT = path.join(TEST_ROOT, 'data', 'ops');

  const TEST_DATA_BASE = args.env === 'node' ? TEST_ROOT : '/base/test/';

  npmlog.verbose('TestRunnerCli.Init', 'Ensure test data folder...');
  fs.ensureSymlinkSync(path.join(__dirname, '../../test/data/node'), TEST_DATA_MODEL_NODE_ROOT, 'junction');
  npmlog.verbose('TestRunnerCli.Init', 'Ensure test data folder... DONE');

  let testlist: Test.TestList;
  const shouldLoadSuiteTestData = args.mode === 'suite0' || args.mode === 'suite1';
  if (shouldLoadSuiteTestData) {
    npmlog.verbose('TestRunnerCli.Init', 'Loading testlist...');

    // The following is a list of unittests for already implemented operators.
    // Modify this list to control what node tests to run.
    const jsonWithComments = fs.readFileSync(path.resolve(TEST_ROOT, './suite-test-list.jsonc')).toString();
    const json = stripJsonComments(jsonWithComments, { whitespace: true });
    testlist = JSON.parse(json) as Test.TestList;
    npmlog.verbose('TestRunnerCli.Init', 'Loading testlist... DONE');
  }

  // The default backends and opset version lists. Those will be used in suite tests.
  const DEFAULT_BACKENDS: readonly TestRunnerCliArgs.Backend[] =
    args.env === 'node' ? ['cpu', 'wasm'] : ['wasm', 'webgl', 'webgpu', 'webnn'];
  const DEFAULT_OPSET_VERSIONS = fs
    .readdirSync(TEST_DATA_MODEL_NODE_ROOT, { withFileTypes: true })
    .filter((dir) => dir.isDirectory() && dir.name.startsWith('opset'))
    .map((dir) => dir.name.slice(5));
  const MAX_OPSET_VERSION = Math.max(...DEFAULT_OPSET_VERSIONS.map((v) => Number.parseInt(v, 10)));

  const FILE_CACHE_ENABLED = args.fileCache; // whether to enable file cache
  const FILE_CACHE_MAX_FILE_SIZE = 1 * 1024 * 1024; // The max size of the file that will be put into file cache
  const FILE_CACHE_SPLIT_SIZE = 4 * 1024 * 1024; // The min size of the cache file
  const fileCache: Test.FileCache = {};

  const nodeTests = new Map<string, Test.ModelTestGroup[]>();
  const onnxTests = new Map<string, Test.ModelTestGroup>();
  const opTests = new Map<string, Test.OperatorTestGroup[]>();

  if (shouldLoadSuiteTestData) {
    npmlog.verbose('TestRunnerCli.Init', 'Loading test groups for suite test...');

    // collect all model test folders
    const allNodeTestsFolders = DEFAULT_OPSET_VERSIONS.map((version) => {
      const suiteRootFolder = path.join(TEST_DATA_MODEL_NODE_ROOT, `opset${version}`);
      if (!fs.existsSync(suiteRootFolder) || !fs.statSync(suiteRootFolder).isDirectory()) {
        throw new Error(`model test root folder '${suiteRootFolder}' does not exist.`);
      }
      return fs.readdirSync(suiteRootFolder).map((f) => `opset${version}/${f}`);
    }).flat();

    for (const backend of DEFAULT_BACKENDS) {
      if (args.backends.indexOf(backend) !== -1) {
        nodeTests.set(backend, loadNodeTests(backend, allNodeTestsFolders));
        opTests.set(backend, loadOpTests(backend));
      }
    }
  }

  if (shouldLoadSuiteTestData) {
    npmlog.verbose('TestRunnerCli.Init', 'Loading test groups for suite test... DONE');

    npmlog.verbose('TestRunnerCli.Init', 'Validate testlist...');
    validateTestList();
    npmlog.verbose('TestRunnerCli.Init', 'Validate testlist... DONE');
  }

  const modelTestGroups: Test.ModelTestGroup[] = [];
  const opTestGroups: Test.OperatorTestGroup[] = [];
  let unittest = false;

  npmlog.verbose('TestRunnerCli.Init', 'Preparing test config...');
  switch (args.mode) {
    case 'suite0':
    case 'suite1':
      for (const backend of DEFAULT_BACKENDS) {
        if (args.backends.indexOf(backend) !== -1) {
          modelTestGroups.push(...nodeTests.get(backend)!); // model test : node
          opTestGroups.push(...opTests.get(backend)!); // operator test
        }
      }
      if (args.mode === 'suite0') {
        unittest = true;
      }
      break;

    case 'model':
      if (!args.param) {
        throw new Error("the test folder should be specified in mode 'node'");
      } else {
        const testFolderSearchPattern = args.param;
        const testFolder = tryLocateModelTestFolder(testFolderSearchPattern);
        for (const b of args.backends) {
          modelTestGroups.push({
            name: testFolder,
            tests: [modelTestFromFolder(testFolder, b, undefined, args.times)],
          });
        }
      }
      break;

    case 'unittest':
      unittest = true;
      break;

    case 'op':
      if (!args.param) {
        throw new Error("the test manifest should be specified in mode 'op'");
      } else {
        const manifestFileSearchPattern = args.param;
        const manifestFile = tryLocateOpTestManifest(manifestFileSearchPattern);
        for (const b of args.backends) {
          opTestGroups.push(opTestFromManifest(manifestFile, b));
        }
      }
      break;
    default:
      throw new Error(`unsupported mode '${args.mode}'`);
  }

  npmlog.verbose('TestRunnerCli.Init', 'Preparing test config... DONE');

  npmlog.info('TestRunnerCli', 'Initialization completed. Start to run tests...');
  run({
    unittest,
    model: modelTestGroups,
    op: opTestGroups,
    log: args.logConfig,
    profile: args.profile,
    downloadModel: args.downloadModel,
    options: {
      sessionOptions: {
        graphOptimizationLevel: args.graphOptimizationLevel,
        optimizedModelFilePath: args.optimizedModelFilePath,
      },
      debug: args.debug,
      cpuOptions: args.cpuOptions,
      webglOptions: args.webglOptions,
      webnnOptions: args.webnnOptions,
      wasmOptions: args.wasmOptions,
      globalEnvFlags: args.globalEnvFlags,
    },
  });
  npmlog.info('TestRunnerCli', 'Tests completed successfully');

  function validateTestList() {
    for (const backend of DEFAULT_BACKENDS) {
      const nodeTest = nodeTests.get(backend);
      if (nodeTest) {
        for (const testCase of testlist[backend].node) {
          const testCaseName = typeof testCase === 'string' ? testCase : testCase.name;
          let found = false;
          for (const testGroup of nodeTest) {
            found ||=
              minimatch.match(
                testGroup.tests.map((test) => test.modelUrl).filter((url) => url !== ''),
                path.join('**', testCaseName, '*.+(onnx|ort)').replace(/\\/g, '/'),
                { matchBase: true },
              ).length > 0;
          }
          if (!found) {
            throw new Error(`node model test case '${testCaseName}' in test list does not exist.`);
          }
        }
      }

      const onnxTest = onnxTests.get(backend);
      if (onnxTest) {
        const onnxModelTests = onnxTest.tests.map((i) => i.name);
        for (const testCase of testlist[backend].onnx) {
          const testCaseName = typeof testCase === 'string' ? testCase : testCase.name;
          if (onnxModelTests.indexOf(testCaseName) === -1) {
            throw new Error(`onnx model test case '${testCaseName}' in test list does not exist.`);
          }
        }
      }

      const opTest = opTests.get(backend);
      if (opTest) {
        const opTests = opTest.map((i) => i.name);
        for (const testCase of testlist[backend].ops) {
          const testCaseName = typeof testCase === 'string' ? testCase : testCase.name;
          if (opTests.indexOf(testCaseName) === -1) {
            throw new Error(`operator test case '${testCaseName}' in test list does not exist.`);
          }
        }
      }
    }
  }

  function loadNodeTests(backend: string, allFolders: string[]): Test.ModelTestGroup[] {
    const allTests = testlist[backend]?.node;

    // key is folder name, value is test index array
    const folderTestMatchCount = new Map<string, number[]>(allFolders.map((f) => [f, []]));
    // key is test category, value is a list of model test
    const opsetTests = new Map<string, Test.ModelTest[]>();

    allTests.forEach((test, i) => {
      const testName = typeof test === 'string' ? test : test.name;
      const matches = minimatch.match(allFolders, path.join('**', testName).replace(/\\/g, '/'));
      matches.forEach((m) => folderTestMatchCount.get(m)!.push(i));
    });

    for (const folder of allFolders) {
      const testIds = folderTestMatchCount.get(folder);
      const times = testIds ? testIds.length : 0;
      if (times > 1) {
        throw new Error(`multiple testlist rules matches test: ${path.join(TEST_DATA_MODEL_NODE_ROOT, folder)}`);
      }

      const test = testIds && testIds.length > 0 ? allTests[testIds[0]] : undefined;
      const platformCondition = test && typeof test !== 'string' ? test.platformCondition : undefined;

      const opsetVersion = folder.split('/')[0];
      const category = `node-${opsetVersion}-${backend}`;
      let modelTests = opsetTests.get(category);
      if (!modelTests) {
        modelTests = [];
        opsetTests.set(category, modelTests);
      }
      modelTests.push(
        modelTestFromFolder(path.resolve(TEST_DATA_MODEL_NODE_ROOT, folder), backend, platformCondition, times),
      );
    }

    return Array.from(opsetTests.keys()).map((category) => ({ name: category, tests: opsetTests.get(category)! }));
  }

  function modelTestFromFolder(
    testDataRootFolder: string,
    backend: string,
    platformCondition?: Test.PlatformCondition,
    times?: number,
  ): Test.ModelTest {
    if (times === 0) {
      npmlog.verbose('TestRunnerCli.Init.Model', `Skip test data from folder: ${testDataRootFolder}`);
      return {
        name: path.basename(testDataRootFolder),
        backend,
        modelUrl: '',
        cases: [],
        ioBinding: args.ioBindingMode,
      };
    }

    let modelUrl: string | null = null;
    let cases: Test.ModelTestCase[] = [];
    let externalData: Array<{ data: string; path: string }> | undefined;

    npmlog.verbose('TestRunnerCli.Init.Model', `Start to prepare test data from folder: ${testDataRootFolder}`);

    try {
      const maybeExternalDataFiles: Array<[fileNameWithoutExtension: string, size: number]> = [];
      for (const thisPath of fs.readdirSync(testDataRootFolder)) {
        const thisFullPath = path.join(testDataRootFolder, thisPath);
        const stat = fs.lstatSync(thisFullPath);
        if (stat.isFile()) {
          const ext = path.extname(thisPath);
          if (ext.toLowerCase() === '.onnx' || ext.toLowerCase() === '.ort') {
            if (modelUrl === null) {
              modelUrl = path.join(TEST_DATA_BASE, path.relative(TEST_ROOT, thisFullPath));
              if (FILE_CACHE_ENABLED && !fileCache[modelUrl] && stat.size <= FILE_CACHE_MAX_FILE_SIZE) {
                fileCache[modelUrl] = bufferToBase64(fs.readFileSync(thisFullPath));
              }
            } else {
              throw new Error('there are multiple model files under the folder specified');
            }
          } else {
            maybeExternalDataFiles.push([path.parse(thisPath).name, stat.size]);
          }
        } else if (stat.isDirectory()) {
          const dataFiles: string[] = [];
          for (const dataFile of fs.readdirSync(thisFullPath)) {
            const dataFileFullPath = path.join(thisFullPath, dataFile);
            const ext = path.extname(dataFile);

            if (ext.toLowerCase() === '.pb') {
              const dataFileUrl = path.join(TEST_DATA_BASE, path.relative(TEST_ROOT, dataFileFullPath));
              dataFiles.push(dataFileUrl);
              if (
                FILE_CACHE_ENABLED &&
                !fileCache[dataFileUrl] &&
                fs.lstatSync(dataFileFullPath).size <= FILE_CACHE_MAX_FILE_SIZE
              ) {
                fileCache[dataFileUrl] = bufferToBase64(fs.readFileSync(dataFileFullPath));
              }
            }
          }
          if (dataFiles.length > 0) {
            cases.push({ dataFiles, name: thisPath });
          }
        }
      }

      if (modelUrl === null) {
        throw new Error('there are no model file under the folder specified');
      }
      // for performance consideration, we do not parse every model. when we think it's likely to have external
      // data, we will parse it. We think it's "likely" when one of the following conditions is met:
      // 1. any file in the same folder has the similar file name as the model file
      //    (e.g., model file is "model_abc.onnx", and there is a file "model_abc.pb" or "model_abc.onnx.data")
      // 2. the file size is larger than 1GB
      const likelyToHaveExternalData = maybeExternalDataFiles.some(
        ([fileNameWithoutExtension, size]) =>
          path.basename(modelUrl!).startsWith(fileNameWithoutExtension) || size >= 1 * 1024 * 1024 * 1024,
      );
      if (likelyToHaveExternalData) {
        const model = onnx.ModelProto.decode(fs.readFileSync(path.join(testDataRootFolder, path.basename(modelUrl!))));
        const externalDataPathSet = new Set<string>();
        for (const initializer of model.graph!.initializer!) {
          if (initializer.externalData) {
            for (const data of initializer.externalData) {
              if (data.key === 'location') {
                externalDataPathSet.add(data.value!);
              }
            }
          }
        }
        externalData = [];
        const externalDataPaths = [...externalDataPathSet];
        for (const dataPath of externalDataPaths) {
          const fullPath = path.resolve(testDataRootFolder, dataPath);
          const url = path.join(TEST_DATA_BASE, path.relative(TEST_ROOT, fullPath));
          externalData.push({ data: url, path: dataPath });
        }
      }
    } catch (e) {
      npmlog.error('TestRunnerCli.Init.Model', `Failed to prepare test data. Error: ${inspect(e)}`);
      throw e;
    }

    const caseCount = cases.length;
    if (times !== undefined) {
      if (times > caseCount) {
        for (let i = 0; cases.length < times; i++) {
          const origin = cases[i % caseCount];
          const duplicated = {
            name: `${origin.name} - copy ${Math.floor(i / caseCount)}`,
            dataFiles: origin.dataFiles,
          };
          cases.push(duplicated);
        }
      } else {
        cases = cases.slice(0, times);
      }
    }

    let ioBinding: Test.IOBindingMode;
    if (!['webgpu', 'webnn'].includes(backend) && args.ioBindingMode !== 'none') {
      npmlog.warn(
        'TestRunnerCli.Init.Model',
        `Ignoring IO Binding Mode "${args.ioBindingMode}" for backend "${backend}".`,
      );
      ioBinding = 'none';
    } else {
      ioBinding = args.ioBindingMode;
    }

    npmlog.verbose('TestRunnerCli.Init.Model', 'Finished preparing test data.');
    npmlog.verbose('TestRunnerCli.Init.Model', '===============================================================');
    npmlog.verbose('TestRunnerCli.Init.Model', ` Model file: ${modelUrl}`);
    npmlog.verbose('TestRunnerCli.Init.Model', ` Backend: ${backend}`);
    npmlog.verbose('TestRunnerCli.Init.Model', ` Test set(s): ${cases.length} (${caseCount})`);
    if (externalData) {
      npmlog.verbose('TestRunnerCli.Init.Model', ` External data: ${externalData.length}`);
      for (const data of externalData) {
        npmlog.verbose('TestRunnerCli.Init.Model', `  - ${data.path}`);
      }
    }
    npmlog.verbose('TestRunnerCli.Init.Model', '===============================================================');

    return {
      name: path.basename(testDataRootFolder),
      platformCondition,
      modelUrl,
      backend,
      cases,
      ioBinding,
      externalData,
    };
  }

  function tryLocateModelTestFolder(searchPattern: string): string {
    const folderCandidates: string[] = [];
    // 1 - check whether search pattern is a directory
    if (fs.existsSync(searchPattern) && fs.lstatSync(searchPattern).isDirectory()) {
      folderCandidates.push(searchPattern);
    }

    // 2 - check the globby result of searchPattern
    // 3 - check the globby result of ONNX root combined with searchPattern
    const globbyPattern = [
      searchPattern,
      path.join(TEST_DATA_MODEL_NODE_ROOT, '**', searchPattern).replace(/\\/g, '/'),
    ];
    // 4 - check the globby result of NODE root combined with opset versions and searchPattern
    globbyPattern.push(
      ...DEFAULT_OPSET_VERSIONS.map((v) =>
        path.join(TEST_DATA_MODEL_NODE_ROOT, `opset${v}`, '**', searchPattern).replace(/\\/g, '/'),
      ),
    );

    folderCandidates.push(...globbySync(globbyPattern, { onlyDirectories: true, absolute: true }));

    // pick the first folder that matches the pattern
    for (const folderCandidate of folderCandidates) {
      const modelCandidates = globbySync('*.{onnx,ort}', { onlyFiles: true, cwd: folderCandidate });
      if (modelCandidates && modelCandidates.length === 1) {
        return folderCandidate;
      }
    }

    throw new Error(`no model folder found: ${searchPattern}`);
  }

  function loadOpTests(backend: string): Test.OperatorTestGroup[] {
    const groups: Test.OperatorTestGroup[] = [];
    for (const thisPath of fs.readdirSync(TEST_DATA_OP_ROOT)) {
      const thisFullPath = path.join(TEST_DATA_OP_ROOT, thisPath);
      const stat = fs.lstatSync(thisFullPath);
      const ext = path.extname(thisFullPath);
      if (stat.isFile() && (ext === '.json' || ext === '.jsonc')) {
        const skip = testlist[backend].ops.indexOf(thisPath) === -1;
        groups.push(opTestFromManifest(thisFullPath, backend, skip));
      }
    }

    return groups;
  }

  function opTestFromManifest(manifestFile: string, backend: string, skip = false): Test.OperatorTestGroup {
    let tests: Test.OperatorTest[] = [];
    const filePath = path.resolve(process.cwd(), manifestFile);
    if (skip) {
      npmlog.verbose('TestRunnerCli.Init.Op', `Skip test data from manifest file: ${filePath}`);
    } else {
      npmlog.verbose('TestRunnerCli.Init.Op', `Start to prepare test data from manifest file: ${filePath}`);
      const jsonWithComments = fs.readFileSync(filePath).toString();
      const json = stripJsonComments(jsonWithComments, { whitespace: true });
      tests = JSON.parse(json) as Test.OperatorTest[];
      // field 'verbose' and 'backend' is not set
      for (const test of tests) {
        test.backend = backend;
        test.opset = test.opset || { domain: '', version: MAX_OPSET_VERSION };
        if (backend !== 'webgpu' && args.ioBindingMode !== 'none') {
          npmlog.warn(
            'TestRunnerCli.Init.Op',
            `Ignoring IO Binding Mode "${args.ioBindingMode}" for backend "${backend}".`,
          );
          test.ioBinding = 'none';
        } else {
          test.ioBinding = args.ioBindingMode;
        }
      }
      npmlog.verbose('TestRunnerCli.Init.Op', 'Finished preparing test data.');
      npmlog.verbose('TestRunnerCli.Init.Op', '===============================================================');
      npmlog.verbose('TestRunnerCli.Init.Op', ` Test Group: ${path.relative(TEST_DATA_OP_ROOT, filePath)}`);
      npmlog.verbose('TestRunnerCli.Init.Op', ` Backend: ${backend}`);
      npmlog.verbose('TestRunnerCli.Init.Op', ` Test case(s): ${tests.length}`);
      npmlog.verbose('TestRunnerCli.Init.Op', '===============================================================');
    }
    return { name: path.relative(TEST_DATA_OP_ROOT, filePath), tests };
  }

  function tryLocateOpTestManifest(searchPattern: string): string {
    for (const manifestCandidate of globbySync(
      [
        searchPattern,
        path.join(TEST_DATA_OP_ROOT, '**', searchPattern).replace(/\\/g, '/'),
        path.join(TEST_DATA_OP_ROOT, '**', searchPattern + '.json').replace(/\\/g, '/'),
        path.join(TEST_DATA_OP_ROOT, '**', searchPattern + '.jsonc').replace(/\\/g, '/'),
      ],
      { onlyFiles: true, absolute: true, cwd: TEST_ROOT },
    )) {
      return manifestCandidate;
    }

    throw new Error(`no OP test manifest found: ${searchPattern}`);
  }

  function run(config: Test.Config) {
    // STEP 1. write file cache to testdata-file-cache-*.json
    npmlog.info('TestRunnerCli.Run', '(1/4) Writing file cache to file: testdata-file-cache-*.json ...');
    const fileCacheUrls = saveFileCache(fileCache);
    if (fileCacheUrls.length > 0) {
      config.fileCacheUrls = fileCacheUrls;
    }
    npmlog.info(
      'TestRunnerCli.Run',
      `(1/4) Writing file cache to file: testdata-file-cache-*.json ... ${
        fileCacheUrls.length > 0 ? `DONE, ${fileCacheUrls.length} file(s) generated` : 'SKIPPED'
      }`,
    );

    // STEP 2. write the config to testdata-config.json
    npmlog.info('TestRunnerCli.Run', '(2/4) Writing config to file: testdata-config.json ...');
    saveConfig(config);
    npmlog.info('TestRunnerCli.Run', '(2/4) Writing config to file: testdata-config.json ... DONE');

    // STEP 3. generate bundle
    npmlog.info('TestRunnerCli.Run', '(3/4) Running build to generate bundle...');
    const buildCommand = `node ${path.join(__dirname, 'build')}`;
    const buildArgs = [`--bundle-mode=${args.env === 'node' ? 'node' : args.bundleMode}`];
    npmlog.info('TestRunnerCli.Run', `CMD: ${buildCommand} ${buildArgs.join(' ')}`);
    const build = spawnSync(buildCommand, buildArgs, { shell: true, stdio: 'inherit' });
    if (build.status !== 0) {
      console.error(build.error);
      process.exit(build.status === null ? undefined : build.status);
    }
    npmlog.info('TestRunnerCli.Run', '(3/4) Running build to generate bundle... DONE');

    if (args.env === 'node') {
      // STEP 5. run tsc and run mocha
      npmlog.info('TestRunnerCli.Run', '(4/4) Running tsc...');
      const tsc = spawnSync('npx', ['tsc'], { shell: true, stdio: 'inherit' });
      if (tsc.status !== 0) {
        console.error(tsc.error);
        process.exit(tsc.status === null ? undefined : tsc.status);
      }
      npmlog.info('TestRunnerCli.Run', '(4/4) Running tsc... DONE');

      npmlog.info('TestRunnerCli.Run', '(4/4) Running mocha...');
      const mochaArgs = [
        'mocha',
        '--timeout',
        `${args.debug ? 9999999 : 60000}`,
        '-r',
        path.join(DIST_ROOT, 'ort.node.min.js'),
        path.join(TEST_ROOT, 'test-main'),
      ];
      npmlog.info('TestRunnerCli.Run', `CMD: npx ${mochaArgs.join(' ')}`);
      const mocha = spawnSync('npx', mochaArgs, { shell: true, stdio: 'inherit' });
      if (mocha.status !== 0) {
        console.error(mocha.error);
        process.exit(mocha.status === null ? undefined : mocha.status);
      }
      npmlog.info('TestRunnerCli.Run', '(4/4) Running mocha... DONE');
    } else {
      // STEP 5. use Karma to run test
      npmlog.info('TestRunnerCli.Run', '(4/4) Running karma to start test runner...');
      const webgpu = args.backends.indexOf('webgpu') > -1;
      const webnn = args.backends.indexOf('webnn') > -1;
      const browser = getBrowserNameFromEnv(args.env);
      const karmaArgs = ['karma', 'start', `--browsers ${browser}`];
      const chromiumFlags = ['--enable-features=SharedArrayBuffer', ...args.chromiumFlags];
      if (args.bundleMode === 'dev' && !args.debug) {
        // use headless for 'test' mode (when 'perf' and 'debug' are OFF)
        chromiumFlags.push('--headless=new');
      }
      if (args.debug) {
        karmaArgs.push('--log-level info --timeout-mocha 9999999');
        chromiumFlags.push('--remote-debugging-port=9333');
      } else {
        karmaArgs.push('--single-run');
      }
      if (args.noSandbox) {
        karmaArgs.push('--no-sandbox');
      }

      // When using BrowserStack with Safari, we need NOT to use 'localhost' as the hostname.
      if (!(browser.startsWith('BS_') && browser.includes('Safari'))) {
        karmaArgs.push('--force-localhost');
      }
      if (webgpu) {
        // flag 'allow_unsafe_apis' is required to enable experimental features like fp16 and profiling inside pass.
        // flag 'use_dxc' is required to enable DXC compiler.
        chromiumFlags.push('--enable-dawn-features=allow_unsafe_apis,use_dxc');
      }
      if (webnn) {
        chromiumFlags.push('--enable-features=WebMachineLearningNeuralNetwork');
      }
      if (process.argv.includes('--karma-debug')) {
        karmaArgs.push('--log-level debug');
      }
      karmaArgs.push(`--bundle-mode=${args.bundleMode}`);
      if (args.userDataDir) {
        karmaArgs.push(`--user-data-dir="${args.userDataDir}"`);
      }
      karmaArgs.push(...chromiumFlags.map((flag) => `--chromium-flags=${flag}`));
      if (browser.startsWith('Edge')) {
        // There are currently 2 Edge browser launchers:
        //  - karma-edge-launcher: used to launch the old Edge browser
        //  - karma-chromium-edge-launcher: used to launch the new chromium-kernel Edge browser
        //

        // Those 2 plugins cannot be loaded at the same time, so we need to determine which launchers to use.
        //  - If we use 'karma-edge-launcher', no plugins config need to be set.
        //  - If we use 'karma-chromium-edge-launcher', we need to:
        //       - add plugin "@chiragrupani/karma-chromium-edge-launcher" explicitly, because it does not match the
        //         default plugins config "^karma-.*"
        //       - remove "karma-edge-launcher".

        // check if we have the latest Edge installed:
        if (
          os.platform() === 'darwin' ||
          (os.platform() === 'win32' &&
            require('@chiragrupani/karma-chromium-edge-launcher/dist/Utilities').default.GetEdgeExe('Edge') !== '')
        ) {
          // use "@chiragrupani/karma-chromium-edge-launcher"
          karmaArgs.push(
            '--karma-plugins=@chiragrupani/karma-chromium-edge-launcher',
            '--karma-plugins=(?!karma-edge-launcher$)karma-*',
          );
        } else {
          // use "karma-edge-launcher"

          // == Special treatment to Microsoft Edge ==
          //
          // == Edge's Auto Recovery Feature ==
          // when Edge starts, if it found itself was terminated forcely last time, it always recovers all previous
          // pages. this always happen in Karma because `karma-edge-launcher` uses `taskkill` command to kill Edge every
          // time.
          //
          // == The Problem ==
          // every time when a test is completed, it will be added to the recovery page list.
          // if we run the test 100 times, there will be 100 previous tabs when we launch Edge again.
          // this run out of resources quickly and fails the further test.
          // and it cannot recover by itself because every time it is terminated forcely or crashes.
          // and the auto recovery feature has no way to disable by configuration/commandline/registry
          //
          // == The Solution ==
          // for Microsoft Edge, we should clean up the previous active page before each run
          // delete the files stores in the specific folder to clean up the recovery page list.
          // see also: https://www.laptopmag.com/articles/edge-browser-stop-tab-restore
          const deleteEdgeActiveRecoveryCommand =
            'del /F /Q % LOCALAPPDATA %\\Packages\\Microsoft.MicrosoftEdge_8wekyb3d8bbwe\\AC\\MicrosoftEdge\\User\\Default\\Recovery\\Active\\*';
          npmlog.info('TestRunnerCli.Run', `CMD: ${deleteEdgeActiveRecoveryCommand}`);
          spawnSync(deleteEdgeActiveRecoveryCommand, { shell: true, stdio: 'inherit' });
        }
      }
      npmlog.info('TestRunnerCli.Run', `CMD: npx ${karmaArgs.join(' ')}`);
      const karma = spawnSync('npx', karmaArgs, { shell: true, stdio: 'inherit' });
      if (karma.status !== 0) {
        console.error(karma.error);
        process.exit(karma.status === null ? undefined : karma.status);
      }
      npmlog.info('TestRunnerCli.Run', '(4/4) Running karma to start test runner... DONE');
    }
  }

  function saveFileCache(fileCache: Test.FileCache) {
    const fileCacheUrls: string[] = [];
    let currentIndex = 0;
    let currentCache: Test.FileCache = {};
    let currentContentTotalSize = 0;
    for (const key in fileCache) {
      const content = fileCache[key];
      if (currentContentTotalSize > FILE_CACHE_SPLIT_SIZE) {
        fileCacheUrls.push(saveOneFileCache(currentIndex, currentCache));
        currentContentTotalSize = 0;
        currentIndex++;
        currentCache = {};
      }
      currentCache[key] = content;
      currentContentTotalSize += key.length + content.length;
    }
    if (currentContentTotalSize > 0) {
      fileCacheUrls.push(saveOneFileCache(currentIndex, currentCache));
    }
    return fileCacheUrls;
  }

  function saveOneFileCache(index: number, fileCache: Test.FileCache) {
    fs.writeFileSync(path.join(TEST_ROOT, `./testdata-file-cache-${index}.json`), JSON.stringify(fileCache));
    return path.join(TEST_DATA_BASE, `./testdata-file-cache-${index}.json`);
  }

  function saveConfig(config: Test.Config) {
    fs.writeJSONSync(path.join(TEST_ROOT, './testdata-config.json'), config);
  }

  function getBrowserNameFromEnv(env: TestRunnerCliArgs['env']) {
    switch (env) {
      case 'chrome':
        return 'ChromeTest';
      case 'chromecanary':
        return 'ChromeCanaryTest';
      case 'edge':
        return 'EdgeTest';
      case 'firefox':
        return 'FirefoxTest';
      case 'electron':
        return 'Electron';
      case 'safari':
        return 'Safari';
      case 'bs':
        return process.env.ORT_WEB_TEST_BS_BROWSERS!;
      default:
        throw new Error(`env "${env}" not supported.`);
    }
  }
}

void main();
